
上一篇把 todo-api 的骨架建好了,build、run、test 都能跑,不過目前唯一的測試只是確認 Ktor 相依載得到,還沒有任何一個測試真的驗證過程式碼,day 01 說過,這個系列講到的行為都要有測試,day 03 會先把這套測試慣例建立起來,用官方的 testApplication 寫下系列第 1 個打請求的測試
testApplication 是什麼,為什麼不用啟動真的 server 也能測完整的請求流程ktor-server-test-host 相依,寫 2 個測試,一個測行為、一個測邊界testApplication 來自 Ktor 官方的 ktor-server-test-host 模組,它做的事情是在測試的 process 裡面直接跑起整個 application,不啟動 Netty、不綁任何 port,你發出的請求不走網路,直接進到 Ktor 的請求處理流程裡,重點是它跑的是跟正式環境同一套 routing 和 plugin,不是什麼簡化過的模擬版,所以 routing 和 plugin 這層測到的行為,就是上線後的行為
跟另一種常見做法「起一個真的 server,再用 HTTP client 從外面打」相比,testApplication 有幾個實際的好處
testApplication 都有自己的 application instance,不會搶 port,要注意 JVM 裡的 top-level、static 或其他 process-wide 狀態仍然共用,這些狀態要另外隔離另外有一個寫法上的細節,testApplication { } 的 lambda 是 suspend context,所以在裡面可以直接呼叫 client.get(...) 這類 suspend 函式,不用自己處理協程,測試函式寫成 fun x() = testApplication { } 就好,至於 client,它是 testApplication 附的預設 HttpClient,已經指向這個測試 application,拿來就能用
看到「不啟動真的 server」這句,合理的下一個問題是,那這種測試到底測到多少 ?
先把「端」講清楚,一個 HTTP 請求從外面進來,會經過作業系統的 socket、engine 的 HTTP 解析、Ktor 的 plugin pipeline、routing、handler,然後原路回去,testApplication 覆蓋的是從 pipeline 到 handler 這一段,而且是完整覆蓋,跟正式環境同一套 plugin、同一棵路由樹、同一份序列化設定
沒有覆蓋到的是最外面 2 層,socket 跟 engine,testApplication 用一個叫 TestEngine 的東西取代掉 Netty,所以連線怎麼建、HTTP 怎麼解、一個格式壞掉的請求會怎樣,這些都不在它的範圍裡
所以這個系列裡的測試比較準確的名字是整合測試,測的是應用程式那幾層整合起來的行為,真正意義上的 end to end,也就是真的起一台 server、綁一個真的 port、從外面用真的 HTTP 打進去,要到 day 34 才會補上,那篇也會量到幾件這裡看不到的事
整合測試快、穩、可以平行跑,適合拿來驗每一篇講到的行為,所以接下來 30 幾篇都用它,end to end 測試貴,適合拿來驗少數幾件「只有真的跑起來才會發現」的事,數量少反而是對的
TestEngine 到底換掉了什麼、換掉之後有哪些後果,day 24 會把它整個拆開,這裡先知道邊界在哪裡就好
在 build.gradle.kts 的 dependencies 區塊加上一行
testImplementation(ktorLibs.server.testHost)
accessor 的命名規則 day 02 講過,把 artifact 名去掉 ktor- 前綴再一層層點下去,所以 ktor-server-test-host 就是 ktorLibs.server.testHost,版本一樣由 version catalog 統一管,不用寫版本號
在 src/test/kotlin/com/cashwu/todo/ApplicationTest.kt 加上
package com.cashwu.todo
import io.ktor.client.request.get
import io.ktor.client.statement.bodyAsText
import io.ktor.http.HttpStatusCode
import io.ktor.server.testing.testApplication
import kotlin.test.Test
import kotlin.test.assertEquals
class ApplicationTest {
@Test
fun `root path responds hello`() = testApplication {
application {
module()
}
val response = client.get("/")
assertEquals(HttpStatusCode.OK, response.status)
assertEquals("Hello, Ktor!", response.bodyAsText())
}
@Test
fun `unknown path responds not found`() = testApplication {
application {
module()
}
val response = client.get("/nothing-here")
assertEquals(HttpStatusCode.NotFound, response.status)
}
}
關鍵是 application { module() } 這一段,它把 day 02 寫的 Application.module() 掛進測試 application,回想一下 Application.kt 裡的 embeddedServer(..., module = Application::module),兩邊掛的是同一個函式,所以測試裡打 / 走到的路由,就是正式跑起來時走到的那一條
2 個測試各自負責一件事,第 1 個測行為,打 / 要拿到 200 和 Hello, Ktor!,第 2 個測邊界,打一個不存在的路徑要拿到 404,這也是接下來整個系列的示範,不會只測 happy path,404 這種「沒對到會怎樣」也是行為的一部分,多花一個測試把它確認下來,之後路由改壞了才會第一時間知道
./gradlew test
我實測的結果是
> Task :test
ApplicationTest > root path responds hello() PASSED
ApplicationTest > unknown path responds not found() PASSED
EnvironmentTest > Ktor EmbeddedServer class is available() PASSED
BUILD SUCCESSFUL in 1s
4 actionable tasks: 1 executed, 3 up-to-date
Consider enabling configuration cache to speed up this build: https://docs.gradle.org/9.7.1/userguide/configuration_cache_enabling.html
3 個測試通過,2 個是這篇新寫的,另一個是 day 02 的環境測試。從這裡開始,./gradlew test 就是整個系列驗證行為的固定入口
application { module() }
少了這段,testApplication 起的是一個空的 application,沒有任何路由,打 / 直接 404,第 1 個測試的斷言錯誤長這樣
org.opentest4j.AssertionFailedError: expected: <200 OK> but was: <404 Not Found>
原因是我們的專案走 embeddedServer 風格,沒有 application.yaml 設定檔,testApplication 沒有設定檔可以自動載入 module,所以要自己掛,之後專案有了設定檔,情況會不一樣
client.get 是 suspend 函式
它不能在一般函式裡直接呼叫,一定要在 testApplication { } 區塊裡面用,因為那個區塊本身就是 suspend context,測試方法的簽名照上面 = testApplication { } 的寫法,就不會遇到編譯器抱怨 suspend 的問題
工具有了,順便把之後每篇的呈現方式講清楚
手刻系列沒有 testApplication 這種東西可以用,測試工具是自己長出來的,那個系列的 收尾回顧 整理過 TestKit 的演進,「TestKit 從 day 06 一個最陽春的版本開始,day 11 讓它支援 routing DSL,day 14 升級成 suspend,day 20 補上 JSON 驗證,day 28 才收斂成 relixTest { }」,一個測試工具花了大半個系列才收斂成好用的形狀
回頭看這篇做的事,加一行相依,testApplication 開箱就是那個「收斂完的形狀」,routing DSL、suspend、可以驗證 response body,全部都在,而且它跑的是真的 Ktor pipeline,不是為了測試另外做的簡化版,框架附帶可測性這件事,平常用的時候感覺不到,自己長過一次測試工具就知道差多少
這篇把系列的測試地基放好了,加上 ktor-server-test-host 相依,用 testApplication 在 process 內跑完整的請求流程,2 個測試分別確認了 / 的回應和不存在路徑的 404,加上 day 02 的環境測試共 3 個測試通過,之後每篇都把列進規格的行為集中寫成測試,提供可重現的驗證步驟
專案能跑、測試慣例也建好了,下一篇開始拆 Ktor 的核心概念,Application 和 engine 是什麼關係、server.core 和 server.netty 為什麼要分成 2 個模組、module 這個函式為什麼長那樣,把 day 02 先當成固定寫法帶過的部分拆開來講
同步刊登於 Blog
圖片來源:AI 產生